流式行为树用于AI和游戏逻辑

本文翻译自Fluent behavior trees for AI and game-logic,by Ashley Davis

你是否需要经济有效的方式使用行为树?流式行为树API能够让程序员变成游戏策划,享受传统的行为树的好处,花更少的开发时间。

这么多年来,我一直对行为树抱有兴趣。它是行之有效的创建AI和游戏逻辑的方式。许多专业公司的游戏AI策划用行为树编辑器创建行为。

独立开发者(或说受资金限制的专业开发者)可能不能负担购买或造一个好的行为树编辑器。多数情况下,游戏开发者必须三头六臂,开发中扮演不同的角色。如果这和你的情境相似,你可能不是有太多的的工具任君使用的专业游戏策划。

本文是技术文档,并提供开源库。我将同时提供一些理论及实践样例。另外我将对比传统的行为树和其他技术

引言

行为树)已经出现在我们视野中有十来年之久了。感谢2005年GDC在光环2的AI的分享中,将其引入了游戏产业。自此它在多方媒体包括影响深远的AiGameDev.com中备受欢迎。

本文陈述的技术同时联合了流式API和强大的行为树。现在已是可用的开源C#库了。我个人曾在Unity商业游戏driving simulator project中使用该技术和类库。该库是常规的C#代码,不限于在Unity中使用。例如你可以轻松应用于MonoGame中。一个相似的库可运行与C++中,任何人可自由移植到其他语言。

本文面向那些寻求廉价但能丰富健壮构建AI方式的游戏开发者。廉价指的是你不需要造一个编辑器,也不需要聘用游戏策划使用它。这机制适用于常扮演开发、美术、策划等的独立游戏开发者。

你若是在公司上班的专业游戏开发者,有专门的AI工具,本文可能帮不到你。那些能够负担造编辑器且聘用策划的公司,请这么做吧。然而流式行为树依旧能够在其他地方帮到你。首先它能让你有行为树的经验,并在学习完全数据驱动行为树系统前进行试水。流式行为树为在学习成熟昂贵系统前,提供了廉价的方式认识行为树。

我在用promise进行游戏开发过程中,萌发关于流式行为树的概念。自我发觉在Real Serious Games的promise库滚雪球般应用后,我甚至写了一篇长文来记录。随着对promise的推进加深,我意识到promise长得像行为树了。这导致我思考行为树的流式API,在某些情况下比promise更灵活更恰当。当然,当开发者开始使用流式API后,会自然而然的创造许多使用它们的机会,这点我并不意外。我知道听起来像马斯洛的锤子,但相信我,我还有许多其他工具。流式行为树是工具包中的另外工具,总能恰当的多次被带出使用。

获取代码

本C#的行为树库按教科书的说明实现。意思就是,经过研究,我实现了标准的游戏开发行为树库。实现起来很容易,同时添加了一些额外的装饰。它可运行于任何.Net 3.5的环境,那是因为该版本和Unity兼容。如有必要,可升至更高版本。虽然仅在window下进行了测试,但我相信同样适用于移动平台。如果你们发现了任何BUG,请在github上告知。

代码托管在github

你可以直接下载,若是想贡献代码,请fork,并提交pull给我。

你也可以用vs编译代码,将DLL放在Unity工程中。另外,你也可以直接将源代码放在Unity工程目录下。

预编译版本可用NuGet下载。

理论

在实践流式行为树的细节前,先复习些简明的理论,并提供些资料加深对行为树的理解。

若你已经精通行为树,请跳过这些章节。

行为树

行为树是构建管理模块及可用AI逻辑的神奇方式。专业的游戏策划使用编辑器创建、修改、调试。开发过程中,他们造了许多可用的库和插件式行为。随着时间的推进,这让构建新实体AI变得更快更容易。随着构建AI的过程变成胶合不同预造行为,然后调整它们的属性。编辑器让快速重写AI变得容易,因此能更快迭代提升游戏可玩性。

行为树表示了实体思考的AI和逻辑。行为树本身无状态,它依赖于实体或环境来保存状态。每次更新时,针对实体或环境状态重新评估行为树。每次更新都是上次更新离开的位置开始。它必须指是什么状态,现在该做什么。

行为树可扩展成大量AI。它本质是树——层次嵌套无限层。这表明他可以表达任意复杂和深度嵌套AI。它们由模块化组件构造,因此可管理很大的树。它的尺度大小仅依赖于我们要限制多大的树、工具可处理多少、电脑性能。

网上有许多介绍指导行为树的资料:它如何工作,如何结构化,和其他AI的对比等。我仅提一些基本的资料,以下是链接

流式API对比传统API

传统行为树是由数据载入,它们由可视化编辑器构建编辑,存于文件或数据库中,是数据驱动。与此相对,流式行为树通过代码API构建,可以说是代码驱动。

为什么采用这个方法呢?

首先,它很廉价,但你依旧可以获取传统行为树的相同好处。可用的第一版流式行为树库可以一天内构建。相对地,需要耗费许多星期去构建个有基本功能的行为树编辑器。假设你是一名程序员,为什么你非要舍近求远,你可以直接码上流式API呀。你可以购买一个编辑器,尤其在Unity商店上有一些选择。尽管花了些银子,也许不贵,但是考虑到你需要投资时间去学习一个特定的包。不仅仅是如何使用编辑器,更包括如何使用API载入并运行行为树。

编写流式API的过程让我非常愉悦。智能提示很好用,编译器理解我的选择并且能自动补全。

在代码中定义行为树给了你结构化的方式去一起破解各种行为树模式。我说的是一起破解。你是否曾有过这样的经历,当你在设计某些很棒,清晰,优雅的东西时,才发现在完成前几个月你要破解原始设计不符合游戏需求而变更的噩梦。这让我学到,有一个能够处理破解的架构是多么重要,这样你必然在某一刻作出一款伟大的游戏。寻找能够支撑这样的模块化架构机制,将增加我们快速工作及适应的能力(我们能随时破解),并最终提升我们代码的设计(因为我们能从写代码并砍掉不必要的功能)。我听说这叫designing for disposability,即是以模块化的方式规划系统同时意识到某些模块将会是被抛弃的一坨翔。流式行为树支持模块化和行为分隔,因此我相信他能提供可处理原则。

并不存在一招吃遍天下的方法。你必须指出自己项目和情形的正确方式,有时制定会适合你。其他时候,你会发现你犯了多大的错误选择一条艰难的路。尝试流式行为树给你的福利能让你避免沉浸过多时间。时间投资最小化,但在时间上的潜在回报是巨大的。这么说来,行为树是个让人喜欢的选择。

没有什么能最终阻止你混入及匹配各种方法。使用流式API做快速原型,编程测试你的想法。然后使用好的编辑器在你需要的时候构建并部署载入行为到最终产品中。

为了有效提升使用流式行为树的周转时间,你需要更快的编译时间。你可能更倾用更小的测试平台而不是整个游戏。最终,倘若你的编译时间会拖累你编码、测试流式行为树的改变。若不能容忍这个问题,传统行为树可能更适合你,仅当你能在运行时热载入它们。因此,当你要购买一个行为树编辑器/系统前,请确认是否有该功能。

Promise对比行为树

倘若不发点时间讲讲promise,可能会产生疏忽吧。

在我讲Promise文章中,提出了将promise推入行为树的国度。我们已经使用promise去管理异步操作,如载入关卡,资源,从数据库载入数据等。当然,这是promise的意义所在也是其所擅长的。

开始使用promise去管理其他诸如视频、声音回放的操作,对我们而言并不是个很大的跨越。毕竟这些操作都异步发生且持续一段时间。当它们完成后,一般将触发某些回调,这样我们能够处理序列中的下一个操作。Promise真擅长于此,毕竟它的威力就在于异步操作的序列链。

当我们认为整个游戏就像异步操作的交织链,就会顿悟。毕竟游戏就是持续多帧的复杂的逻辑链网络。我们扩展promise支持游戏逻辑组合,此时,我意识到行为树可能更适合这么做。然而,那是已是项目的后半程且那时我们木有行为树库。所以我们继续并完成了项目。

从promise上我们获益良多,若我们结合promise和行为树,可能会更好吧。

我们能让promise更像行为树,但把promise这儿做会有问题,行为树的性质就是解决上述问题。和promise类似,行为树也允许组合或链接逻辑——(异步)发生多帧或游戏循环的迭代。主要的区别点在于他们如何时钟更新,行为树一般更适合游戏循环的一步接一步的方式。行为树只在游戏循环的每次迭代更新。当行为树停止其更新时钟,行为树逻辑将停止,不再处理。因此取消或退出行为树运行变得极其简单:直接停止更新即可。

但是运行中的promise并不像行为树那样容易退出。他被设计成在异步操作中自由飞翔,触发也不由时钟或更新函数影响。这意味着除非他完成或发生错误,你几乎无对操作的运行时控制。例如从网上下载文件……它会一直运行直到完成或报错。终止promise链通常调用注入另外的特别promise,它会直接抛出异常事件而使整个promise链都reject。可以用Promise.Race引入可终止promise来针对要中止的promise来实现。这听起来困难且阵痛,因为就是这样。闹铃就该响起。

自从我在产品中使用流式行为树后,我同样理论验证了promise和行为树可以容易的结合在一起,且二者长处都可以发挥。下一步我将展示例子了。

实践

本章节描述如何使用流式行为树库

行为树状态

行为树节点将返回以下状态码:

  • Success: 节点完成任务并成功。
  • Failure: 节点完成,但失败了。
  • Running: 节点依旧在运行。

基本用法

行为树对象由 BehaviourTreeBuilder 创建,Build函数将返回完整树的根节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
using FluentBehaviourTree;
...
IBehaviourTreeNode tree;
public void Startup()
{
var builder = new BehaviourTreeBuilder();
this.tree = builder
.Sequence("my-sequence")
.Do("action1", t => {
// ... do something ...
return BehaviourTreeStatus.Success;
})
.Do("action2", t => {
// ... do something after ...
return BehaviourTreeStatus.Success;
})
.End()
.Build();
}

注意,必须在游戏循环中添加行为树的时钟更新。

1
2
3
4
public void Update(float deltaTime)
{
this.tree.Tick(new TimeData(deltaTime));
}

节点名

上述代码在创建节点时指定了名字。

这些名字纯粹为了测试及调试的目的。这让树可视化渲染,更容易看到AI的状态。另外提一下 调试可视化 同样在游戏开发中非常重要,它能帮助理解和调试游戏在做啥。

节点类型

提供了以下的行为树节点类型

行为/叶子节点,Action/Leaf

Do函数创建了行为节点,成为行为树的叶子节点。返回的状态(Success、Failure、Running)值指明了目前的节点状态。
叶子节点

串行节点

串行执行每个子节点。一旦子节点失败,其也宣告失败态。前一个子节点成功后才会执行下一个子节点。当子节点还在运行时,返回运行态。所有子节点都成功后,返回成功态。

1
2
3
4
5
6
7
8
9
10
11
12
.Sequence("my-sequence")
.Do("action1", t =>
{
// Sequential action 1.
return BehaviourTreeStatus.Success; // Run this.
});
.Do("action2", t =>
{
// Sequential action 2.
return BehaviourTreeStatus.Success; // Then run this.
})
.End()

串行节点

并行节点

所有子节点并行执行。直到特定数目的子节点成功或失败后才返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int numRequiredToFail = 2;
int numRequiredToSucceed = 2;
.Parallel("my-parallel", numRequiredToFail, numRequiredToSucceed)
.Do("action1", t =>
{
// Parallel action 1.
return BehaviourTreeStatus.Running;
});
.Do("action2", t =>
{
// Parallel action 2.
return BehaviourTreeStatus.Running;
})
.End()

并行节点

选择节点

串行执行子节点,直到某个子节点成功。找到第一个成功的子节点,返回成功态。前一个子节点失败后才移步下一个。子节点运行时不会换成下一个子节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
.Selector("my-selector")
.Do("action1", t =>
{
// Action 1.
return BehaviourTreeStatus.Failure; // Fail, move onto next child.
});
.Do("action2", t =>
{
// Action 2.
return BehaviourTreeStatus.Success; // Success, stop here.
})
.Do("action3", t =>
{
// Action 3.
return BehaviourTreeStatus.Success; // Doesn't get this far.
})
.End()

选择节点

条件节点

条件节点是Do的语法糖。它运行返回布尔值,然后转化成节点状态表示。通常配合选择节点使用。

1
2
3
4
.Selector("my-selector")
.Condition("condition", t=>SomeCondition())
.Do("action", t=>SomeAction())
.End()

条件节点

反转节点

反转子节点的状态(Success、Failure)。子节点为运行态时,返回Running。

1
2
3
4
5
6
7
8
9
.Inverter("inverter")
// *Success* will be inverted to *failure*.
.Do("action", t => BehaviourTreeStatus.Success)
.End()
.Inverter("inverter")
// *Failure* will be inverted to *success*.
.Do("action", t => BehaviourTreeStatus.Failure)
.End()

嵌套行为树

行为树可以任意深度进行嵌套,如:

1
2
3
4
5
6
7
8
9
10
11
12
.Selector("parent")
.Sequence("child-1")
...
.Parallel("grand-child")
...
.End()
...
.End()
.Sequence("child-2")
...
.End()
.End()

行为复用

分别建立的子树可以合并到父树中。这使得行为树能复用,易于创建。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
private IBehaviourTreeNode CreateSubTree()
{
var builder = new BehaviourTreeBuilder();
return builder
.Sequence("my-sub-tree")
.Do("action1", t =>
{
// Action 1.
return BehaviourTreeStatus.Success;
});
.Do("action2", t =>
{
// Action 2.
return BehaviourTreeStatus.Success;
});
.End()
.Build();
}
public void Startup()
{
var builder = new BehaviourTreeBuilder();
this.tree = builder
.Sequence("my-parent-sequence")
.Splice(CreateSubTree()) // Splice the child tree in.
.Splice(CreateSubTree()) // Splice again.
.End()
.Build();
}

Promise + 行为树

下例为理想化例子展示结合行为树及promise的威力,通过 promise timer综合在一起。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PromiseTimer promiseTimer = new PromiseTimer();
public void Update(float deltaTime)
{
promiseTimer.Update(deltaTime);
}
public IPromise StartActivity()
{
IBehaviourTreeNode behaviorTree = ... create your behavior tree ...
return promiseTimer.WaitUntil(t =>
behaviorTree.Update(t.elapsedTime) == BehaviourTreeStatus.Success
);
}

StartActivity启动的活动表示行为树,当行为树完成后使用promise的定时器WaitUtil去resolve promise。这仅是简单的方式组合行为树和promise。

可以简单的重载WaitUtil函数,传入行为树作为参数来直接提升使用。

1
2
3
4
5
6
7
8
9
public IPromise StartActivity()
{
IBehaviourTreeNode behaviorTree = ... create your behavior tree ...
return promiseTimer.WaitUntil(
behaviorTree,
BehaviourTreeStatus.Success
);
}

真实例子

现在展示下真是例子。代码源自the driving simulator project。例子很大,但从方案上来看,使用行为树实际上非常简单。赛车模拟驾驶的AI非常复杂,但是用行为树,这一切变得更可控。

先提供些帮助函数来使用行为树。因此实际的的检索、更新实体及环境都委托给帮助函数来实现。细节不表,我仅高屋建瓴的展示行为树的高层逻辑。

以下代码构建了赛车行为树AI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
behaviourTree = builder
.Begin()
.Parallel("All", 20, 20)
.Do("ComputeWaypointDist", t => ComputeWaypointDist())
// Always try to go at at the speed limit.
.Do("SpeedLimit", t => SpeedLimit())
.Sequence("Cornering")
.Condition("IsApproachingCorner",
t => IsApproachingCorner()
)
// Slow down vehicles that are approaching a corner.
.Do("Cornering", t => ApplyCornering())
.End()
// Always attempt to detect other vehicles.
.Do("Detect", t => DetectOtherVehicles())
.Sequence("React to blockage")
.Condition("Approaching Vehicle",
t => IsApproachingVehicle()
)
// Always attempt to match speed with the vehicle in front.
.Do("Match Speed", t => MatchSpeed())
.End()
.Selector("Stuff")
// Slow down for give way or stop sign.
.Sequence("Traffic Light")
.Condition("IsApproaching", t => IsApproachingSignal())
// Slow down for the stop sign.
.Do("ApproachStopSign", t => ApproachStopSign())
// Wait for complete stop.
.Do("WaitForSpeedZero", t => WhenSpeedIsZero())
// Wait at stop sign until the way is clear.
.Do("WaitForGreenSignal", t => WaitForGreenSignal())
.Selector("NextWaypoint Or Recycle")
.Condition("SelectNextWaypoint",
t => TargetNextWaypoint()
)
// If selection of waypoint fails, recycle vehicle.
.Do("Recycle", t => RecycleVehicle())
.End()
.End()
// Slow down for give way or stop sign.
.Sequence("Stop Sign")
.Condition("IsApproaching",
t => IsApproachingStopSign()
)
// Slow down for the stop sign.
.Do("ApproachStopSign", t => ApproachStopSign())
// Wait for complete stop.
.Do("WaitForSpeedZero", t => WhenSpeedIsZero())
// Wait at stop sign until the way is clear.
.Do("WaitForClearAwareness",
t => WaitForClearAwarenessZone()
)
.Selector("NextWaypoint Or Recycle")
.Condition("SelectNextWaypoint",
t => TargetNextWaypoint()
)
// If selection of waypoint fails, recycle vehicle.
.Do("Recycle", t => RecycleVehicle())
.End()
.End()
.Sequence("Follow path then recycle")
.Do("ApproachWaypoint", t => ApproachWaypoint())
.Selector("NextWaypoint Or Recycle")
.Condition("SelectNextWaypoint",
t => TargetNextWaypoint()
)
// If selection of waypoint fails, recycle vehicle.
.Do("Recycle", t => RecycleVehicle())
.End()
.End()
.End()
// Drive the vehicle based on desired speed and direction.
.Do("Drive", t => DriveVehicle())
.End()
.End()
.Build();

流程图如下:
流程图

以下关于交通灯逻辑的子树
交通灯子树

以下是路径跟随逻辑的子树
路径跟随子树

例子我遮盖了了大量细节。我更想在高处用流程图阐释代码中的树结构。

结论

本文中,我解释了如何用流式API编码行为树。本文聚焦于行为树库及其例子。这技术我已经在实际商业Unity项目中使用了。我期待找未来能找到更多使用流式行为树的机会,并持续构想并分享在这里。

若是你有改进库的想法,请fork并提交你的代码。同样有问题也请告诉我。

图标这么闪,你不点一下吗